07.4 精通自定义 View 之 绘图进阶——Shader 与 BitmapShader

返回自定义 View 目录

7.4.1 Shader 概述

Shader 在三维软件中称之为着色器,是用来给空白图形上色用的。在 PhotoShop 中有一个印章工具,能够指定印章的样式来填充图形。印章的样式可以是图像、颜色、渐变色等。这里的 Shader 实现的效果与印章类似。我们也是通过给 Shader 指定对应的图像、渐变色等来填充图形的。Paint 中有一个函数专门用于设置 Shader,其声明如下:

1
public Shader setShader(Shader shader)

Shader 类只是一个基类,其中只有两个函数 setLocalMatrix(Matrix localM) 和 getLocalMatrix(Matrix localM),用来设置坐标变换矩阵的。

Shader 类其实是一个空类,它的功能主要是靠它的派生类来实现的。继承关系如下图所示。

7.4.2 BitmapShader 的基本用法

它的构造函数如下:

1
public BitmapShader(Bitmap bitmap, TileMode tileX, TileMode tileY)

这就相当于 PhotoShop 中的印章工具,bitmap 用来指定图案,tileX 用来指定当 X 轴超出单个图片大小时时所使用的重复策略,同样 tileY 用于指定当 Y 轴超出单个图片大小时时所使用的重复策略。

其中TileMode的取值有:

  • TileMode.CLAMP:用边缘色彩填充多余空间。
  • TileMode.REPEAT:重复原图像来填充多余空间。
  • TileMode.MIRROR:重复使用镜像模式的图像来填充多余空间。

1. 示例

这里使用的印章图像如下图所示 (dog.png)。

中间是一幅小狗头像,四周被四种不同的颜色给包围。设置 Shader 的完整代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class TestView extends View {
private Paint mPaint;
public TestView(Context context, AttributeSet attrs) {
super(context, attrs);
mPaint = new Paint();
Bitmap mBitmap = BitmapFactory.decodeResource(getResources(), R.drawable.dog_edge);
mPaint.setShader(new BitmapShader(mBitmap, Shader.TileMode.REPEAT, Shader.TileMode.REPEAT));
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
canvas.drawRect(0, 0, getWidth(), getHeight(), mPaint);
}
}

效果图如下所示:

给自定义的控件添加上宽高限制:

1
2
3
4
5
6
7
<!--<com.xxt.xtest.TestView
android:layout_width="match_parent"
android:layout_height="match_parent" />-->
<com.xxt.xtest.TestView
android:layout_width="300dp"
android:layout_height="450dp"
android:layout_gravity="center_horizontal"/>

效果图如下:

从效果图中可以看出:

  • 在 X 轴和 Y 轴都使用 REPEAT 模式下,在超出单个图像的区域后,就会重复绘制这个图像。
  • 绘制是从控件的左上角开始的,而不是从屏幕原点开始的。这点很好理解,因为我们只会在自定义控件上绘图,不会在全屏幕上绘图。

2. TileMode 模式解析

上面初步看到了 REPEAT 模式的用法,现在我们分别来看在各个模式下的不同表现。
1)TileMode.REPEAT 模式:重复原图像来填充多余空间
在更改模式时,只需要更新 setShader 里的代码:

1
mPaint.setShader(new BitmapShader(mBmp, TileMode.REPEAT, TileMode.REPEAT));

在这里,X 轴、Y 轴全部设置成 REPEAT 模式,所以当控件的显示范围超出了单个图的显示范围时,在 X 轴上将使用 REPEAT 模式;同样,在 Y 轴上也将使用 REPEAT 模式。

2)TileMode.MIRROR 模式:重复使用镜像模式的图像来填充多余空间
同样,将 X 轴、Y 轴全部改为 MIRROR 模式:

1
mPaint.setShader(new BitmapShader(mBmp, TileMode.MIRROR, TileMode.MIRROR));

效果如下图所示。

在 X 轴上每两张图片的显示都像镜子一样翻转一下。同样,在 Y 轴上每两张图片的显示也都像镜子一样翻转一下。所以这就是镜相效果的作用,镜像效果其实就是在显示下一图片的时候,就相当于两张图片中间放了一个镜子一样。

3)TileMode.CLAMP:用边缘色彩填充多余空间

1
mPaint.setShader(new BitmapShader(mBmp, TileMode.CLAMP, TileMode.CLAMP));

CLAMP 模式的意思就是当控件区域超过当前单个图片的大小时,空白位置的颜色填充就用图片的边缘颜色来填充。

4)TileMode.CLAMP 与填充顺序
当 X 轴、Y 轴全部都是 CLAMP 模式时,X 轴的空白区域会用图像的右侧边缘颜色来填充;Y 轴的空白区域会用图像的底部的边缘颜色来填充,那效果应该是这样的:

明显右下角的空白位置根本与图像是不沾边的,那它要用什么颜色来填充呢?是填充上方的蓝色还是填充左侧的绿色呢?

从最终的效果图来看,这部分填充的颜色是绿色的,可为什么呢?其实这是跟填充顺序有关的,并且是先填充竖向再填充横向。如果是先填充横向再填充竖向,那么右下角颜色应该是蓝色。

4)使用混合填充模式
比如在 X 轴填充空白区域时使用 MIRROR 样式、在填充 Y 轴空白区域时使用REPEAT样式:

1
mPaint.setShader(new BitmapShader(mBmp, TileMode.MIRROR, TileMode.REPEAT));

从效果图中可以看出来,首先使用 REPEAT 模式填充 Y 轴,然后使用 MIRROR 模式填充 X 轴。

总之:无论哪两种模式混合或者相同模式,都是先填充 Y 轴,然后填充 X 轴。

3. 绘图位置与图像显示

在上面的例子中,我们利用 drawRect 把整个控件大小都给覆盖了,那假如我们只画一个小矩形而不完全覆盖整个控件,那我们 setShader() 函数中所设置的图片是从哪里开始画的呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class TestView extends View {
private Paint mPaint;
public TestView(Context context, AttributeSet attrs) {
super(context, attrs);
mPaint = new Paint();
Bitmap mBitmap = BitmapFactory.decodeResource(getResources(), R.drawable.dog_edge);
mPaint.setShader(new BitmapShader(mBitmap, Shader.TileMode.REPEAT, Shader.TileMode.REPEAT));
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
float left = getWidth() / 3f;
float top = getHeight() / 3f;
float right = getWidth() * 2f / 3;
float bottom = getHeight() * 2f / 3;
canvas.drawRect(left,top,right,bottom, mPaint);
}
}

即在绘图时,并不是完全覆盖控件大小的,而是取控件中间位置的 1/3 区域显示的。效果如下图所示。

7.4.3 示例一:望远镜效果

这里要实现的效果是:根据手指所在的位置,把对应的图像绘制出来。这样看起来就是望远镜效果了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
public class TestView extends View {
private Paint mPaint;
private Rect mRect;
private int mDx = -1;
private int mDy = -1;
private Bitmap mBitmap, mBitmapBg;
private int mRadius = 300;
public TestView(Context context, AttributeSet attrs) {
super(context, attrs);
mPaint = new Paint();
mBitmap = BitmapFactory.decodeResource(getResources(), R.drawable.meinv);
mRect = new Rect();
}
@Override
public boolean onTouchEvent(MotionEvent event) {
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
mDx = (int) event.getX();
mDy = (int) event.getY();
invalidate();
return true;
case MotionEvent.ACTION_MOVE:
mDx = (int) event.getX();
mDy = (int) event.getY();
break;
case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_CANCEL:
mDx = -1;
mDy = -1;
break;
}
invalidate();
return super.onTouchEvent(event);
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
if (mBitmapBg == null) {
mBitmapBg = Bitmap.createBitmap(getWidth(), getHeight(), Bitmap.Config.ARGB_8888);
Canvas canvasBg = new Canvas(mBitmapBg);
mRect.set(0, 0, getWidth(), getHeight());
canvasBg.drawBitmap(mBitmap, null, mRect, mPaint);
}
if (mDx != -1 && mDy != -1) {
mPaint.setShader(new BitmapShader(mBitmapBg, Shader.TileMode.REPEAT, Shader.TileMode.REPEAT));
canvas.drawCircle(mDx, mDy, mRadius, mPaint);
}
}
}

我们主要来看下 OnDraw() 函数:
在 onDraw() 函数中,第一部分,就是新建一个空白的 bitmap,这个 bitmap 的大小与控件一样,然后把我们的背景图进行拉伸,画到这个空白的 bitmap 上。由于这里的 canvasBg 是用 mBitmapBg 创建的,所以所画的任何图像都会直接显示在 mBitmapBg 上,而我们创建的 mBitmapBg 是与控件一样大的,所以当把 mBitmapBg 做为 Shader 来设置给 mPaint 时,mBitmapBg 会正好覆盖整个控件,而不会有多余的空白像素。

这里需要注意的就是我们在将原图像画到 mBitmapBg 时,进行了拉伸压缩,把它拉伸到和当前控件一样大小。然后利用 OnMotionEvent 来捕捉用户的手指位置,当用户手指下按时,在手指位置画一个半径为 mRadius 的圆形,把对应的位置的图像显示出来就可以了。

7.4.4 示例二:生成不规则头像

res/values/attrs.xml

1
2
3
4
5
6
7
8
9
10
11
<?xml version="1.0" encoding="utf-8"?>
<resources>
<declare-styleable name="TestView">
<attr name="src" format="reference" />
<attr name="format" format="enum">
<enum name="circle" value="0"/>
<enum name="rectTangle" value="1"/>
</attr>
<attr name="radius" format="integer" />
</declare-styleable>
</resources>

自定义控件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
public class TestView extends View {
private Paint mPaint;
private Bitmap mBitmap;
private BitmapShader mBitmapShader;
private int mEnumFormat = 0;
private int mRadius = 5;
private RectF mRectF;
private Matrix mMatrix;
public TestView(Context context, AttributeSet attrs) throws Exception{
super(context, attrs);
init(context,attrs);
}
public TestView(Context context, AttributeSet attrs, int defStyle) throws Exception{
super(context, attrs, defStyle);
init(context,attrs);
}
private void init(Context context,AttributeSet attrs) throws Exception{
// 提取属性定义
TypedArray typedArray = context.obtainStyledAttributes(attrs,R.styleable.TestView);
int BitmapID = typedArray.getResourceId(R.styleable.TestView_src,-1);
if (BitmapID == -1){
throw new Exception("TestView 需要定义 Src 属性,而且必须是图像");
}
mBitmap = BitmapFactory.decodeResource(getResources(),BitmapID);
mEnumFormat = typedArray.getInt(R.styleable.TestView_format,0);
if (mEnumFormat == 1){
mRadius = typedArray.getInt(R.styleable.TestView_radius,5);
}
typedArray.recycle();
mPaint = new Paint();
mBitmapShader = new BitmapShader(mBitmap, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP);
mRectF = new RectF();
mMatrix = new Matrix();
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
int measureWidth = MeasureSpec.getSize(widthMeasureSpec);
int measureHeight = MeasureSpec.getSize(heightMeasureSpec);
int measureWidthMode = MeasureSpec.getMode(widthMeasureSpec);
int measureHeightMode = MeasureSpec.getMode(heightMeasureSpec);
int width = mBitmap.getWidth();
int height = mBitmap.getHeight();
width = (measureWidthMode == MeasureSpec.EXACTLY) ? measureWidth: width;
height = (measureHeightMode == MeasureSpec.EXACTLY) ? measureHeight: height;
setMeasuredDimension(width, height);
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
float scale = (float) getWidth()/mBitmap.getWidth();
mMatrix.reset();
mMatrix.setScale(scale, scale);
mBitmapShader.setLocalMatrix(mMatrix);
mPaint.setShader(mBitmapShader);
float half = getWidth() / 2f;
if (mEnumFormat == 0){
canvas.drawCircle(half, half, getWidth() / 2f, mPaint);
} else if(mEnumFormat == 1){
mRectF.set(0, 0, getWidth(), getHeight());
canvas.drawRoundRect(mRectF, mRadius, mRadius, mPaint);
}
}
}

在 XML 中使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="20dp"
android:orientation="vertical">
<com.xxt.xtest.TestView
android:layout_width="50dp"
android:layout_height="50dp"
app:src="@drawable/meinv"
app:format="circle"/>
<com.xxt.xtest.TestView
android:layout_width="100dp"
android:layout_height="100dp"
app:src="@drawable/head"
app:format="circle"/>
<com.xxt.xtest.TestView
android:layout_width="100dp"
android:layout_height="100dp"
android:layout_marginTop="10dp"
app:src="@drawable/meinv"
app:format="rectTangle"
app:radius="10"/>
<com.xxt.xtest.TestView
android:layout_width="130dp"
android:layout_height="130dp"
android:layout_marginTop="10dp"
app:src="@drawable/head"
app:format="rectTangle"
app:radius="30"/>
<com.xxt.xtest.TestView
android:layout_width="150dp"
android:layout_height="150dp"
android:layout_marginTop="10dp"
app:src="@drawable/meinv"
app:format="rectTangle"
app:radius="70"/>
</LinearLayout>